agentmux_srv\backend\wcore/
block.rs

1#![allow(dead_code)]
2// Copyright 2025-2026, AgentMux Corp.
3// SPDX-License-Identifier: Apache-2.0
4
5//! Block CRUD operations.
6
7use uuid::Uuid;
8
9use crate::backend::storage::wstore::WaveStore;
10use crate::backend::storage::StoreError;
11use crate::backend::obj::*;
12
13/// Create a new block in a tab.
14pub fn create_block(
15    store: &WaveStore,
16    tab_id: &str,
17    meta: MetaMapType,
18) -> Result<Block, StoreError> {
19    let mut tab = store.must_get::<Tab>(tab_id)?;
20
21    let mut block = Block {
22        oid: Uuid::new_v4().to_string(),
23        parentoref: format!("tab:{}", tab_id),
24        meta,
25        ..Default::default()
26    };
27    store.insert(&mut block)?;
28
29    tab.blockids.push(block.oid.clone());
30    store.update(&mut tab)?;
31
32    Ok(block)
33}
34
35/// Delete a block from its parent tab and prune it from the layout tree.
36pub fn delete_block(
37    store: &WaveStore,
38    tab_id: &str,
39    block_id: &str,
40) -> Result<(), StoreError> {
41    let mut tab = store.must_get::<Tab>(tab_id)?;
42    tab.blockids.retain(|id| id != block_id);
43    store.update(&mut tab)?;
44    store.delete::<Block>(block_id)?;
45
46    // Prune the deleted block's node from the layout tree so it doesn't
47    // leave a blank pane. The frontend also removes the node, but if the
48    // frontend update races with the delete or is lost, the orphaned node
49    // persists in the database.
50    if !tab.layoutstate.is_empty() {
51        if let Ok(Some(mut layout)) = store.get::<LayoutState>(&tab.layoutstate) {
52            tracing::info!(
53                block_id = %block_id,
54                layout_id = %tab.layoutstate,
55                "pruning deleted block from layout tree"
56            );
57            prune_block_from_layout(&mut layout, block_id);
58            let _ = store.update(&mut layout);
59        }
60    }
61    Ok(())
62}
63
64/// Remove all references to `block_id` from a layout's rootnode tree and leaforder.
65fn prune_block_from_layout(layout: &mut LayoutState, block_id: &str) {
66    // Prune leaforder
67    if let Some(ref mut leaves) = layout.leaforder {
68        leaves.retain(|entry| entry.blockid != block_id);
69    }
70
71    // Rootnode: handle the single-pane case where rootnode IS the orphan
72    // leaf (no `children` array, just `data.blockId`). `prune_node` only
73    // touches the `children` array, so a rootnode-leaf orphan would
74    // otherwise persist forever. See SPEC_LAYOUT_HEAL_ROOTNODE_ORPHAN.md.
75    //
76    // Phase E.4.B Phase 2 — operates on typed LayoutNode (was JSON walks).
77    let root_is_orphan = layout.rootnode
78        .as_ref()
79        .and_then(|r| r.data.as_ref())
80        .map(|d| d.block_id == block_id)
81        .unwrap_or(false);
82    if root_is_orphan {
83        layout.rootnode = None;
84    } else if let Some(ref mut root) = layout.rootnode {
85        prune_node(root, block_id);
86    }
87}
88
89/// Recursively remove child nodes whose `data.block_id` matches `block_id`.
90/// If removing a child leaves a parent with only one child, collapse by
91/// promoting the sole child to replace the parent node.
92///
93/// Phase E.4.B Phase 2 — operates on typed LayoutNode (was JSON walks).
94fn prune_node(node: &mut LayoutNode, block_id: &str) {
95    // Remove children whose leaf data references the doomed block.
96    node.children.retain(|child| {
97        child
98            .data
99            .as_ref()
100            .map(|d| d.block_id != block_id)
101            .unwrap_or(true) // keep group nodes (no leaf data)
102    });
103    // Recurse into remaining children
104    for child in node.children.iter_mut() {
105        prune_node(child, block_id);
106    }
107    // If only one child remains, promote it to replace this split node.
108    // This avoids degenerate single-child splits in the layout tree.
109    if node.children.len() == 1 {
110        let parent_id = node.id.clone();
111        let parent_size = node.size;
112        let sole_child = node.children.remove(0);
113        *node = sole_child;
114        // Preserve the parent's id and size, replace everything else.
115        node.id = parent_id;
116        node.size = parent_size;
117    }
118}
119
120/// Validate a layout against the set of existing block IDs, removing any
121/// orphaned leaf nodes. Called on tab activation as a self-healing pass.
122pub fn heal_layout(
123    store: &WaveStore,
124    tab_id: &str,
125) -> Result<bool, StoreError> {
126    let tab = store.must_get::<Tab>(tab_id)?;
127    if tab.layoutstate.is_empty() {
128        return Ok(false);
129    }
130    let mut layout = match store.get::<LayoutState>(&tab.layoutstate)? {
131        Some(l) => l,
132        None => return Ok(false),
133    };
134
135    let valid_blocks: std::collections::HashSet<&str> =
136        tab.blockids.iter().map(|s| s.as_str()).collect();
137
138    let (changed, orphans) = heal_layout_body(&mut layout, &valid_blocks);
139    if !changed {
140        return Ok(false);
141    }
142    tracing::warn!(
143        tab_id = %tab_id,
144        orphan_count = orphans.len(),
145        orphans = ?orphans,
146        "healing layout: removing orphaned block nodes"
147    );
148    store.update(&mut layout)?;
149    Ok(true)
150}
151
152/// Pure heal pass on a LayoutState given the set of block IDs that should
153/// still be present. Returns `(changed, orphans_removed)`.
154///
155/// Collects orphans from both `leaforder` AND any leaf still reachable in
156/// `rootnode` — `leaforder` can drift out of sync with `rootnode` if a
157/// write was interrupted, and the heal is the last defense. Prunes each
158/// orphan via `prune_block_from_layout`, then clears `focusednodeid` if
159/// the rootnode ended up empty.
160fn heal_layout_body(
161    layout: &mut LayoutState,
162    valid_blocks: &std::collections::HashSet<&str>,
163) -> (bool, Vec<String>) {
164    // Orphans visible via leaforder.
165    let mut orphans: Vec<String> = layout.leaforder
166        .as_ref()
167        .map(|leaves| {
168            leaves.iter()
169                .filter(|e| !valid_blocks.contains(e.blockid.as_str()))
170                .map(|e| e.blockid.clone())
171                .collect()
172        })
173        .unwrap_or_default();
174
175    // Orphans reachable only via rootnode (leaforder might be clean while
176    // rootnode retains a stale leaf — the inverse of the case that
177    // originally motivated the heal).
178    if let Some(ref root) = layout.rootnode {
179        collect_leaf_block_ids(root, &mut |id| {
180            if !valid_blocks.contains(id) && !orphans.iter().any(|o| o == id) {
181                orphans.push(id.to_string());
182            }
183        });
184    }
185
186    if orphans.is_empty() {
187        return (false, orphans);
188    }
189
190    for orphan_id in &orphans {
191        prune_block_from_layout(layout, orphan_id);
192    }
193
194    // If rootnode dropped, the focused-node pointer is now dangling.
195    if layout.rootnode.is_none() && !layout.focusednodeid.is_empty() {
196        layout.focusednodeid = String::new();
197    }
198
199    (true, orphans)
200}
201
202/// Walk a layout-tree node, calling `sink` once per leaf `data.block_id`.
203/// Phase E.4.B Phase 2 — typed traversal (was JSON walks).
204fn collect_leaf_block_ids(node: &LayoutNode, sink: &mut dyn FnMut(&str)) {
205    if !node.children.is_empty() {
206        for child in &node.children {
207            collect_leaf_block_ids(child, sink);
208        }
209        return;
210    }
211    if let Some(data) = &node.data {
212        sink(&data.block_id);
213    }
214}
215
216// ── Tests ───────────────────────────────────────────────────────────────
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use serde_json::json;
222
223    fn empty_layout(oid: &str) -> LayoutState {
224        LayoutState {
225            oid: oid.to_string(),
226            version: 1,
227            rootnode: None,
228            leaforder: None,
229            focusednodeid: String::new(),
230            magnifiednodeid: String::new(),
231            pendingbackendactions: None,
232            meta: None,
233        }
234    }
235
236    fn leaf(id: &str, block_id: &str, size: f32) -> LayoutNode {
237        LayoutNode {
238            id: id.into(),
239            flex_direction: FlexDirection::Row,
240            size,
241            children: Vec::new(),
242            data: Some(LayoutNodeData {
243                block_id: block_id.into(),
244                ..Default::default()
245            }),
246            ..Default::default()
247        }
248    }
249
250    fn single_leaf_layout(block_id: &str, node_id: &str) -> LayoutState {
251        LayoutState {
252            oid: "layout-1".into(),
253            version: 1,
254            rootnode: Some(leaf(node_id, block_id, 10.0)),
255            leaforder: Some(vec![LeafOrderEntry {
256                blockid: block_id.into(),
257                nodeid: node_id.into(),
258            }]),
259            focusednodeid: node_id.into(),
260            magnifiednodeid: String::new(),
261            pendingbackendactions: None,
262            meta: None,
263        }
264    }
265
266    fn two_leaf_split_layout(left_block: &str, right_block: &str) -> LayoutState {
267        LayoutState {
268            oid: "layout-1".into(),
269            version: 1,
270            rootnode: Some(LayoutNode {
271                id: "split-1".into(),
272                flex_direction: FlexDirection::Row,
273                size: 10.0,
274                children: vec![
275                    leaf("leaf-left", left_block, 5.0),
276                    leaf("leaf-right", right_block, 5.0),
277                ],
278                data: None,
279                ..Default::default()
280            }),
281            leaforder: Some(vec![
282                LeafOrderEntry { blockid: left_block.into(), nodeid: "leaf-left".into() },
283                LeafOrderEntry { blockid: right_block.into(), nodeid: "leaf-right".into() },
284            ]),
285            focusednodeid: "leaf-left".into(),
286            magnifiednodeid: String::new(),
287            pendingbackendactions: None,
288            meta: None,
289        }
290    }
291
292    fn set<'a>(ids: &[&'a str]) -> std::collections::HashSet<&'a str> {
293        ids.iter().copied().collect()
294    }
295
296    #[test]
297    fn prune_removes_rootnode_leaf_when_it_is_orphan() {
298        // THE BUG: single-pane layout where rootnode IS the orphan leaf.
299        // prune_node's old implementation only walked `children`, so rootnode
300        // stayed. This test fails on pre-fix main.
301        let mut layout = single_leaf_layout("orphan-block", "node-1");
302        prune_block_from_layout(&mut layout, "orphan-block");
303
304        assert!(layout.rootnode.is_none(),
305            "rootnode must be cleared when it was the orphan leaf");
306        assert_eq!(
307            layout.leaforder.as_deref().map(|l| l.len()),
308            Some(0),
309            "leaforder entry must be removed too",
310        );
311    }
312
313    #[test]
314    fn prune_removes_child_leaf_and_collapses() {
315        // Regression of existing multi-pane behavior.
316        let mut layout = two_leaf_split_layout("keep", "drop");
317        prune_block_from_layout(&mut layout, "drop");
318
319        assert!(layout.rootnode.is_some(), "rootnode should survive");
320        // The split should collapse to just the "keep" leaf.
321        // Phase E.4.B Phase 2 — typed access (was JSON walks).
322        let root = layout.rootnode.as_ref().unwrap();
323        let kept_block = root.data.as_ref().map(|d| d.block_id.as_str());
324        assert_eq!(kept_block, Some("keep"),
325            "after pruning 'drop' and collapsing, rootnode should be the 'keep' leaf");
326    }
327
328    #[test]
329    fn prune_noop_when_block_absent() {
330        let mut layout = single_leaf_layout("other-block", "node-1");
331        let before = serde_json::to_string(&layout.rootnode).unwrap();
332        prune_block_from_layout(&mut layout, "not-present");
333        let after = serde_json::to_string(&layout.rootnode).unwrap();
334        assert_eq!(before, after);
335    }
336
337    #[test]
338    fn heal_body_clears_focused_nodeid_when_rootnode_drops() {
339        let mut layout = single_leaf_layout("orphan-block", "node-1");
340        assert_eq!(layout.focusednodeid, "node-1");
341        let (changed, _orphans) = heal_layout_body(&mut layout, &set(&[]));
342        assert!(changed);
343        assert!(layout.rootnode.is_none());
344        assert_eq!(layout.focusednodeid, "",
345            "focusednodeid must be cleared when rootnode is empty");
346    }
347
348    #[test]
349    fn heal_body_catches_rootnode_orphan_missing_from_leaforder() {
350        // Malformed save: rootnode leaf points at an orphan but leaforder is
351        // already clean (or absent). The healer must still prune rootnode.
352        let mut layout = single_leaf_layout("orphan-block", "node-1");
353        layout.leaforder = Some(vec![]); // leaforder doesn't mention it
354
355        let (changed, orphans) = heal_layout_body(&mut layout, &set(&[]));
356        assert!(changed, "healer must notice rootnode-only orphan");
357        assert!(orphans.contains(&"orphan-block".to_string()));
358        assert!(layout.rootnode.is_none());
359    }
360
361    #[test]
362    fn heal_body_idempotent_on_clean_layout() {
363        let mut layout = single_leaf_layout("live-block", "node-1");
364        let (changed, _) = heal_layout_body(&mut layout, &set(&["live-block"]));
365        assert!(!changed, "no orphans → no change");
366        // Run again; still no change.
367        let (changed_again, _) = heal_layout_body(&mut layout, &set(&["live-block"]));
368        assert!(!changed_again);
369    }
370
371    #[test]
372    fn heal_body_handles_empty_layout() {
373        let mut layout = empty_layout("empty-layout");
374        let (changed, orphans) = heal_layout_body(&mut layout, &set(&[]));
375        assert!(!changed);
376        assert!(orphans.is_empty());
377    }
378}
379
380/// Resolve a block ID from an 8-character prefix within a tab.
381pub fn resolve_block_id_from_prefix(
382    store: &WaveStore,
383    tab_id: &str,
384    prefix: &str,
385) -> Result<String, StoreError> {
386    if prefix.len() != 8 {
387        return Err(StoreError::Other(
388            "block_id prefix must be 8 characters".to_string(),
389        ));
390    }
391    let tab = store.must_get::<Tab>(tab_id)?;
392    for block_id in &tab.blockids {
393        if block_id.starts_with(prefix) {
394            return Ok(block_id.clone());
395        }
396    }
397    Err(StoreError::NotFound)
398}